Explorar el Código

Create plugin-network with AWSClientPlugin and TransfrSharePlugin.

master
Palash Bansal hace 2 años
padre
commit
9d6a3456e8
No account linked to committer's email address

+ 28
- 0
plugins/network/package-lock.json Ver fichero

@@ -0,0 +1,28 @@
{
"name": "@threepipe/plugin-network",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@threepipe/plugin-network",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"aws4fetch": "^1.0.18",
"threepipe": "file:./../../src/"
},
"devDependencies": {}
},
"../../src": {},
"node_modules/aws4fetch": {
"version": "1.0.18",
"resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.18.tgz",
"integrity": "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="
},
"node_modules/threepipe": {
"resolved": "../../src",
"link": true
}
}
}

+ 59
- 0
plugins/network/package.json Ver fichero

@@ -0,0 +1,59 @@
{
"name": "@threepipe/plugin-network",
"description": "Network/AWS/Cloud related plugins for threepipe",
"version": "0.1.0",
"devDependencies": {
},
"dependencies": {
"threepipe": "file:./../../src/",
"aws4fetch": "^1.0.18"
},
"clean-package": {
"remove": [
"clean-package",
"scripts",
"devDependencies",
"//",
"markdown-to-html"
],
"replace": {
"dependencies": {},
"peerDependencies": {
"threepipe": "^0.0.30"
}
}
},
"type": "module",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist",
"src"
],
"scripts": {
"new:pack": "npm run prepare && clean-package && npm pack && clean-package restore",
"new:publish": "npm run prepare && clean-package && npm publish --access public && clean-package restore",
"prepare": "npm run build && npm run docs",
"build": "rimraf dist && vite build",
"dev": "NODE_ENV=development vite build --watch",
"docs": "rimraf docs && npx typedoc"
},
"author": "repalash <palash@shaders.app>",
"license": "Apache-2.0",
"keywords": [
"three",
"three.js",
"threepipe",
"vite",
"plugin"
],
"bugs": {
"url": "https://github.com/repalash/threepipe/issues"
},
"homepage": "https://github.com/repalash/threepipe#readme",
"repository": {
"type": "git",
"url": "git://github.com/repalash/threepipe.git"
}
}

+ 288
- 0
plugins/network/src/AWSClientPlugin.ts Ver fichero

@@ -0,0 +1,288 @@
import {
AViewerPluginSync,
FileTransferPlugin,
pathJoin,
serialize,
ThreeViewer,
timeout,
uiButton,
uiFolderContainer,
uiInput,
UiObjectConfig,
uiToggle,
} from 'threepipe'
import {AwsClient, AwsV4Signer} from 'aws4fetch'

/**
* AWSClientPlugin
* Provides `fetch` function that performs a fetch request with AWS v4 signing.
* This is useful for connecting to AWS services like S3 directly from the client.
* It also interfaces with the {@link FileTransferPlugin} to directly upload file when exported with the viewer or the plugin.
* Note: Make sure to use keys with limited privileges and correct CORS settings.
* All the keys will be stored in plain text if `serializeSettings` is set to true
*
* {@todo Make an example for AWSClient Plugin}
*/
@uiFolderContainer('AWS/S3 Client')
export class AWSClientPlugin extends AViewerPluginSync<'fileUpload'> {
static readonly PluginType = 'AWSClientPlugin1'
uiConfig?: UiObjectConfig

enabled = true
private _connected = false

// do not serialize in exported file.
readonly serializeWithViewer = false

private _client: AwsClient | undefined

dependencies = [FileTransferPlugin]

constructor() {
super()
}

@serialize()
@uiInput('Access Key ID', (t: AWSClientPlugin)=>({
disabled: ()=>!t.enabled || t._connected,
}))
accessKeyId = ''

@serialize()
@uiInput('Access Key Secret', (t: AWSClientPlugin)=>({
disabled: ()=>!t.enabled || t._connected,
}))
accessKeySecret = ''

@serialize()
@uiInput('Endpoint URL', (t: AWSClientPlugin)=>({
disabled: ()=>!t.enabled || t._connected,
}))
endpointURL = ''

@serialize()
@uiInput('Path Prefix', (t: AWSClientPlugin)=>({
disabled: ()=>!t.enabled,
}))
pathPrefix = 'webgi'

@serialize()
@uiToggle('Remember', (t: AWSClientPlugin)=>({
disabled: ()=>!t.enabled || t._connected,
}))
serializeSettings = false

@uiButton(undefined, (t: AWSClientPlugin)=>({
label: ()=>t._connected ? 'Disconnect' : 'Connect',
}))
toggleConnection = ()=>{
if (this._connected) {
this.disconnect()
} else {
this.connect()
}
}

/**
* Set to true to use a proxy for all requests.
* This can be used to move the access credentials to the server side or set custom headers.
* This is required for some services like cloudflare R2 that do not support CORS.
* usage: `AWSClientPlugin.USE_PROXY = true`, optionally set `AWSClientPlugin.PROXY_URL` to a custom proxy.
*/
static USE_PROXY = false
static PROXY_URL = 'https://r2-s3-api.repalash.com/{path}'

connect() {

if (this._connected) this.disconnect()
this._client = new AwsClient({
accessKeyId: this.accessKeyId,
secretAccessKey: this.accessKeySecret,
})
this._connected = true

this.refreshUi()

}

refreshUi() {
this.uiConfig?.uiRefresh?.(true, 'postFrame')
}

disconnect() {

this._client = undefined
this._connected = false
this.refreshUi()

}

get connected(): boolean {
return this._connected
}
get client(): AwsClient | undefined {
return this._client
}

toJSON(meta?: any): any {
if (!this.serializeSettings) return {type: (this as any).constructor.PluginType}
return super.toJSON(meta)
}

private _savedExportFile?: FileTransferPlugin['defaultActions']['exportFile']
onAdded(viewer: ThreeViewer) {
super.onAdded(viewer)

const tr = viewer.getPlugin(FileTransferPlugin)!
this._savedExportFile = tr.actions.exportFile || tr.defaultActions.exportFile
if (!this._savedExportFile) throw new Error('FileTransferPlugin must have exportFile action')
tr.actions.exportFile = this.exportFile
}
onRemove(viewer: ThreeViewer) {
const tr = viewer.getPlugin(FileTransferPlugin)!
tr.actions.exportFile = this._savedExportFile!
this._savedExportFile = undefined
super.onRemove(viewer)
}

exportFile: FileTransferPlugin['defaultActions']['exportFile'] = async(blob, name, onProgress)=>{
const viewer = this._viewer
if (!viewer) return
const tr = viewer.getPlugin(FileTransferPlugin)
if (!tr) return
const defaultExport = this._savedExportFile ?? tr.defaultActions.exportFile
if (!this._connected) {
await defaultExport(blob, name)
return
}
const path = pathJoin([this.endpointURL, this.pathPrefix, name])
const response = await this.fetch(path, {
method: 'PUT',
body: blob,
}, onProgress)
if (!response.ok) {
viewer.console.error('Error uploading file', response)
await defaultExport(blob, name)
return
}
this.dispatchEvent({type: 'fileUpload', name, blob, response, path})
viewer.console.log('File uploaded', response)
}

fetchFunction = fetch
async fetch(input: RequestInfo, init: RequestInit, _onProgress?: (d: {state?: string, progress?: number})=>void) {
if (!this._client) throw new Error('Not connected')
for (let i = 0; i <= this._client.retries; i++) {

// todo: add onProgress (using futch in dom.ts?): https://github.com/github/fetch/issues/89

const signed = await sign2(this._client, input, init)
let url = signed.url.toString()

if (AWSClientPlugin.USE_PROXY && url && !url.includes(AWSClientPlugin.PROXY_URL)) {
// const options: RequestInit = {
// headers: signed.headers,
// method: signed.method,
// body: signed.body,
// // ts-expect-error this is a valid option
// // duplex: 'half', // todo; get from request?
// }

// https://github.com/sindresorhus/ky/blob/2af72bfa7a391662a8ee6b1671979069f7f20737/source/core/Ky.ts#L176
// https://issues.chromium.org/issues/40237822
// if (supportsRequestStreams) {
// // @ts-expect-error - Types are outdated.
// options.duplex = 'half'
// }
url = AWSClientPlugin.PROXY_URL.replace('{path}', url)
}

// try {
// signed = new Request(url, options)
// } catch (e) {
// if (e instanceof TypeError) {
// // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
// signed = new Request(url, Object.assign({duplex: 'half'}, options))
// } else throw e
// }

const f = this.fetchFunction // required to first put it in a variable and then call.
const fetched = f(url, signed)
if (i === this._client.retries) {
return fetched // No need to await if we're returning anyway
}
const res = await fetched
if (res.status < 500 && res.status !== 429) {
return res
}
await timeout(Math.random() * this._client.initRetryMs * Math.pow(2, i))
}
throw new Error('An unknown error occurred, ensure retries is not negative')
}

}

export type AwsRequestInit = RequestInit & {
aws?: {
accessKeyId?: string | undefined;
secretAccessKey?: string | undefined;
sessionToken?: string | undefined;
service?: string | undefined;
region?: string | undefined;
cache?: Map<string, ArrayBuffer> | undefined;
datetime?: string | undefined;
signQuery?: boolean | undefined;
appendSessionToken?: boolean | undefined;
allHeaders?: boolean | undefined;
singleEncode?: boolean | undefined;
} | undefined;
}

export async function sign2(client: AwsClient, input: RequestInfo, init?: AwsRequestInit) {
if (input instanceof Request) {
const {method, url, headers, body} = input
init = Object.assign({method, url, headers}, init)
if (init.body == null && headers.has('Content-Type')) {
init.body = body != null && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer()
}
input = url
console.warn('There could be a bug in chrome with cloning Request objects, see https://bugs.chromium.org/p/chromium/issues/detail?id=1360943')
}
const signer = new AwsV4Signer(Object.assign({url: input}, init, client, init && init.aws))
const signed = Object.assign({}, init, await signer.sign())
delete signed.aws
return signed
// try {
// return new Request(signed.url.toString(), signed)
// } catch (e) {
// if (e instanceof TypeError) {
// // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
// return new Request(signed.url.toString(), Object.assign({duplex: 'half'}, signed))
// }
// throw e
// }
}

// https://github.com/sindresorhus/ky/blob/main/source/core/constants.ts
// https://issues.chromium.org/issues/40237822
// todo: right now we are using try catch like in aws4fetch
// export const supportsRequestStreams = (() => {
// let duplexAccessed = false
// let hasContentType = false
// const supportsReadableStream = typeof globalThis.ReadableStream === 'function'
// const supportsRequest = typeof globalThis.Request === 'function'
//
// if (supportsReadableStream && supportsRequest) {
// hasContentType = new globalThis.Request('https://empty.invalid', {
// body: new globalThis.ReadableStream(),
// method: 'POST',
// // @ts-expect-error - Types are outdated.
// get duplex() {
// duplexAccessed = true
// return 'half'
// },
// }).headers.has('Content-Type')
// }
//
// return duplexAccessed && !hasContentType
// })()

+ 127
- 0
plugins/network/src/TransfrSharePlugin.ts Ver fichero

@@ -0,0 +1,127 @@
import {AssetExporterPlugin, AViewerPluginSync, IObject3D, uiButton, uiFolderContainer, uiInput} from 'threepipe'

/**
* Transfr Share Plugin
* A sample plugin that provides helpers to export and upload scene to a server and get a shareable link.
* It uses the options from the {@link AssetExporterPlugin} to export the scene or object, and can be configured using it's ui.
*
* Uses the free service [transfr.one](https://transfr.one/) by default which deletes the files after a certain time,
* but the url can be changed to a custom backend or a self-hosted version of transfr.
*
* Note: since the uploaded files are publicly accessible by anyone by default, it is recommended to encrypt the file using the exporter options or use a secure backend.
*/
@uiFolderContainer('Share Link')
export class TransfrSharePlugin extends AViewerPluginSync<''> {
public static readonly PluginType = 'TransfrSharePlugin'

toJSON: any = null
enabled = true

dependencies = [AssetExporterPlugin]

@uiInput('Server URL')
serverUrl = 'https://bee.transfr.one/scene.glb'
@uiInput()
queryParam = 'm'
@uiInput()
pageUrl = window.location.href

baseUrls: Record<string, string> = {
'editor': '',
'viewer': '',
}

async exportObject(model?: IObject3D) {
const exporter = this._viewer?.getPlugin(AssetExporterPlugin)
if (!this._viewer) throw new Error('TransfrSharePlugin: AssetExporter not found')
return model ?
this._viewer.export(model, exporter?.exportOptions ?? {format: 'glb'}) :
this._viewer.exportScene(exporter?.exportOptions ?? {})
}

/**
* Export and get the link of the 3d model or scene
* @param model
*/
async getLink(model?: IObject3D) {
const obj = await this.exportObject(model)
if (!obj) {
throw new Error('Failed to export object or scene')
}
const path = 'transfr.one/scene.glb'
this._viewer!.assetManager.setProcessState(path, {
state: 'Uploading',
// progress: data.progress ? data.progress * 100 : undefined,
})
const res = await fetch(this.serverUrl, {
method: 'PUT',
body: obj,
})
if (res.status !== 200) {
throw new Error('Failed to upload file')
}
const data = (await res.text())?.trim()
this._viewer!.assetManager.setProcessState(path, undefined)
// console.log(data)
try {
new URL(data)
} catch (e) {
throw new Error('Invalid URL ' + data)
}
return data
}
private _exporting = false

/**
* Upload the scene and copy the link to clipboard along with the base url and query param if provided
* @param base
* @param param
*/
async shareLink(base?: string|URL, param?: string) {
if (this._exporting) return null
this._exporting = true
let link = await this.getLink().catch(e=>{
this._viewer?.console.error(e)
this._viewer?.dialog.alert('Error: Failed to share scene: \n' + e.message)
return null
})

if (link) {
if (base) {
const url = typeof base === 'string' ?
new URL(this.baseUrls[base] ?? base) : base
url.searchParams.set(param || this.queryParam || 'm', link)
link = url.href
}
let copied = false
try {
if (window && window.navigator && navigator.clipboard) {
await navigator.clipboard.writeText(link)
copied = true
}
} catch (e) {
console.error('Failed to copy link', e)
}
this._viewer?.dialog.alert('Link' + (copied ? ' Copied' : '') + ': ' + link + '\n\nNote: File will be deleted in 1 days')
}
this._exporting = false
return link
}

@uiButton('Share editor link', (t: TransfrSharePlugin)=>({hidden: ()=>!t.baseUrls.editor}))
async shareEditorLink() {
return this.shareLink(this.baseUrls.editor, this.queryParam)
}
@uiButton('Share viewer link', (t: TransfrSharePlugin)=>({hidden: ()=>!t.baseUrls.viewer}))
async shareViewerLink() {
return this.shareLink(this.baseUrls.viewer, this.queryParam)
}
@uiButton('Share page link', (t: TransfrSharePlugin)=>({hidden: ()=>!t.pageUrl}))
async sharePageLink() {
return this.shareLink(this.pageUrl, this.queryParam)
}
@uiButton('Share glb link')
async shareGlb() {
return this.shareLink()
}
}

+ 40
- 0
plugins/network/src/global.d.ts Ver fichero

@@ -0,0 +1,40 @@
declare module '*.txt' {
const content: string
export default content
}
declare module '*.glsl' {
const content: string
export default content
}
declare module '*.vert' {
const content: string
export default content
}
declare module '*.frag' {
const content: string
export default content
}
declare module '*.module.scss' {
const content: any
export default content
export const stylesheet: string
}
declare module '*.module.css' {
const content: any
export default content
export const stylesheet: string
}
declare module '*.css' {
const content: string
export default content
}
declare module '*.css?inline' { // for vite
const content: string
export default content
}

// export {}

// hack for typedoc
// eslint-disable-next-line @typescript-eslint/naming-convention
// declare type OffscreenCanvas = HTMLCanvasElement

+ 2
- 0
plugins/network/src/index.ts Ver fichero

@@ -0,0 +1,2 @@
export {AWSClientPlugin} from './AWSClientPlugin'
export {TransfrSharePlugin} from './TransfrSharePlugin'

+ 41
- 0
plugins/network/tsconfig.json Ver fichero

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"baseUrl": "./src",
"rootDir": "./src",
"allowJs": true,
"checkJs": false,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"isolatedModules": true,
"module": "es2020",
"noImplicitAny": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "dist",
"outDir": "dist",
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"removeComments": false,
"preserveConstEnums": true,
"moduleResolution": "node",
"emitDecoratorMetadata": false,
"sourceMap": true,
"target": "ES2021",
"strictNullChecks": true,
"lib": [
"es2020",
"esnext",
"dom"
]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"**/*.spec.ts",
"dist"
]
}

+ 10
- 0
plugins/network/typedoc.json Ver fichero

@@ -0,0 +1,10 @@
{
"extends": [
"../../typedoc.json"
],
"entryPoints": [
"src/index.ts"
],
"name": "Threepipe Network/Cloud Plugins",
"readme": "none"
}

+ 90
- 0
plugins/network/vite.config.js Ver fichero

@@ -0,0 +1,90 @@
import {defineConfig} from 'vite'
import json from '@rollup/plugin-json';
import dts from 'vite-plugin-dts'
import packageJson from './package.json';
import license from 'rollup-plugin-license';
import replace from '@rollup/plugin-replace';
import glsl from 'rollup-plugin-glsl';
import path from 'node:path';

const isProd = process.env.NODE_ENV === 'production'
const { name, version, author } = packageJson
const {main, module, browser} = packageJson

const globals = {
'three': 'threepipe', // just incase someone uses three
'threepipe': 'threepipe',
}

export default defineConfig({
optimizeDeps: {
exclude: ['uiconfig.js', 'ts-browser-helpers'],
},
base: '',
// define: {
// 'process.env': process.env
// },
build: {
sourcemap: true,
minify: false,
cssMinify: isProd,
cssCodeSplit: false,
watch: !isProd ? {
buildDelay: 1000,
} : null,
lib: {
entry: 'src/index.ts',
formats: isProd ? ['es', 'umd'] : ['es'],
name: name,
fileName: (format) => (format === 'umd' ? main : module).replace('dist/', ''),
},
outDir: 'dist',
emptyOutDir: isProd,
commonjsOptions: {
exclude: [/uiconfig.js/, /ts-browser-helpers/],
},
rollupOptions: {
output: {
// inlineDynamicImports: false,
globals,
},
external: Object.keys(globals),

},
},
plugins: [
isProd ? dts({tsconfigPath: './tsconfig.json'}) : null,
replace({
'from \'three\'': 'from \'threepipe\'',
delimiters: ['', ''],
}),
replace({
'process.env.NODE_ENV': JSON.stringify(isProd ? 'production' : 'development'),
preventAssignment: true,
}),
glsl({ // todo: minify glsl.
include: 'src/**/*.glsl',
}),
json(),
// postcss({
// modules: false,
// autoModules: true, // todo; issues with typescript import css, because inject is false
// inject: false,
// minimize: isProduction,
// // Or with custom options for `postcss-modules`
// }),
license({
banner: `
@license
${name} v${version}
Copyright 2022<%= moment().format('YYYY') > 2022 ? '-' + moment().format('YYYY') : null %> ${author}
${packageJson.license} License
See ./dependencies.txt for any bundled third-party dependencies and licenses.
`,
thirdParty: {
output: path.join(__dirname, 'dist', 'dependencies.txt'),
includePrivate: true, // Default is false.
},
}),
],
})

Cargando…
Cancelar
Guardar