mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 16:40:32 +00:00
feat: add gblob scheme support for extension (#1358)
This commit is contained in:
18 files changed
+2664
-147
No files matched your search
@@ -0,0 +1,233 @@
|
||||
# gblob Protocol
|
||||
|
||||
## Background
|
||||
|
||||
Gopeed extensions can resolve a download target into something that is not a normal remote URL.
|
||||
Typical examples are:
|
||||
|
||||
- text generated by an extension
|
||||
- binary data assembled in JavaScript
|
||||
- a stream produced incrementally inside the extension runtime
|
||||
|
||||
Returning a plain `http` or `https` URL is not enough for these cases.
|
||||
The `gblob` protocol provides an internal object-URL style transport so extensions can hand generated content to the downloader in a browser-like way.
|
||||
|
||||
The extension-facing model is intentionally close to the browser:
|
||||
|
||||
- `URL.createObjectURL(new Blob(...))`
|
||||
- `URL.createObjectURL(writableStream)`
|
||||
- `URL.revokeObjectURL(url)`
|
||||
|
||||
The returned URL looks like:
|
||||
|
||||
```text
|
||||
gblob:<id>
|
||||
```
|
||||
|
||||
`gblob` is a process-local protocol. The source only exists inside the current Gopeed process.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- make extension-generated data download as a normal task
|
||||
- support both eager `Blob` data and incremental `WritableStream` data
|
||||
- avoid large in-memory buffering by spooling to disk
|
||||
- keep pause/continue behavior intuitive
|
||||
- allow an extension to recreate a new `gblob` URL after source expiration
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The implementation has two layers in this directory:
|
||||
|
||||
- [`registry.go`](./registry.go): process-local source registry and spool-file manager
|
||||
- [`fetcher.go`](./fetcher.go): downloader fetcher implementation for the `gblob` scheme
|
||||
|
||||
The end-to-end flow is:
|
||||
|
||||
1. JavaScript creates a `Blob` or `WritableStream` object URL.
|
||||
2. Go registers a `gblob` source and assigns it a unique `gblob:<id>` URL.
|
||||
3. Source bytes are written into a local spool file.
|
||||
4. The downloader resolves the `gblob` URL through the `gblob` fetcher.
|
||||
5. The fetcher copies bytes from the spool file into the final download target.
|
||||
|
||||
## Registry
|
||||
|
||||
The registry owns all active `gblob` sources for the current process.
|
||||
|
||||
Each source tracks:
|
||||
|
||||
- source id and `gblob` URL
|
||||
- local spool-file path
|
||||
- source type: `blob` or `writable_stream`
|
||||
- written byte count
|
||||
- source state: `open`, `closed`, `aborted`, `failed`
|
||||
- revoke state
|
||||
- pin count
|
||||
- optional extension session reference
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- `Blob` sources are written once and immediately closed.
|
||||
- `WritableStream` sources stay open until the writer closes or aborts.
|
||||
- readers wait on a per-source notification channel when no new bytes are available yet.
|
||||
- revocation only prevents future use; cleanup happens when the source is no longer pinned.
|
||||
|
||||
## Fetcher
|
||||
|
||||
The fetcher treats `gblob` as a local file-backed download source.
|
||||
|
||||
### Resolve
|
||||
|
||||
`Resolve()` looks up the source in the registry and builds a single-file `Resource`.
|
||||
|
||||
Behavior differences by source type:
|
||||
|
||||
- `Blob`: resolved size is the current written size
|
||||
- `WritableStream`: resolved size is reported as `0` until the writer closes
|
||||
|
||||
`Range` is always `false`.
|
||||
|
||||
### Start
|
||||
|
||||
`Start()` opens the final target file and reads from the source spool file.
|
||||
|
||||
There are two resume modes:
|
||||
|
||||
- same `gblob` id: continue from the current target-file size
|
||||
- different `gblob` id: truncate the target file and restart from byte `0`
|
||||
|
||||
The fetcher persists the last started source id so it can distinguish these two cases across pause/continue and task restore.
|
||||
|
||||
### Pause / Continue
|
||||
|
||||
`Pause()` stops the current copy loop by canceling the reader side.
|
||||
It does not abort the `gblob` source.
|
||||
|
||||
If the source id stays the same, `Continue()` resumes from the existing target-file offset.
|
||||
This is how normal in-process `Pause -> Continue` works.
|
||||
|
||||
### Source Replacement
|
||||
|
||||
If an extension replaces `req.url` with a new `gblob:<new-id>` before the next start, the fetcher detects the id change and restarts from scratch.
|
||||
|
||||
This behavior is important for recovery flows such as:
|
||||
|
||||
- original `gblob` source expired
|
||||
- extension recreates a new `gblob` URL in `onError`
|
||||
- task continues with the new source
|
||||
|
||||
## Lifecycle Notes
|
||||
|
||||
### In-process pause and continue
|
||||
|
||||
For the same `gblob` id:
|
||||
|
||||
- the partially downloaded output file is kept
|
||||
- the fetcher resumes from the current file size
|
||||
- `WritableStream` producers may continue writing while the task is paused
|
||||
|
||||
### Process restart
|
||||
|
||||
`gblob` sources are not persisted across process restarts.
|
||||
An unfinished `gblob` task is restored without a live source behind its old URL.
|
||||
|
||||
The intended recovery pattern is:
|
||||
|
||||
1. user clicks continue
|
||||
2. the old `gblob` source fails
|
||||
3. extension `onError` runs
|
||||
4. extension recreates a new `gblob` URL from `req.rawUrl`
|
||||
5. extension updates `ctx.task.meta.req.url`
|
||||
6. extension calls `ctx.task.continue()`
|
||||
|
||||
Because the new URL uses a different `gblob` id, the fetcher restarts from byte `0`.
|
||||
|
||||
## Why `rawUrl` Exists
|
||||
|
||||
When an extension resolves a request into `gblob:<id>`, the original site URL would otherwise be lost.
|
||||
`Request.RawURL` keeps the original creation URL so lifecycle events can still reason about the original target.
|
||||
|
||||
Extension event URL matching prefers `req.rawUrl` when present.
|
||||
|
||||
This is especially useful for `onStart`, `onError`, and `onDone` on `gblob` tasks.
|
||||
|
||||
## Extension Usage
|
||||
|
||||
### 1. Create a `Blob` object URL
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const blob = new Blob(["hello world"], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
ctx.res = {
|
||||
name: "example",
|
||||
files: [
|
||||
{
|
||||
name: "hello.txt",
|
||||
size: 11,
|
||||
req: { url }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create a streaming object URL
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const stream = new TransformStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
const url = URL.createObjectURL(stream.writable);
|
||||
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
})();
|
||||
|
||||
ctx.res = {
|
||||
name: "stream-example",
|
||||
files: [
|
||||
{
|
||||
name: "stream.txt",
|
||||
req: { url }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Recover in `onError`
|
||||
|
||||
```js
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
const req = ctx.task.meta.req;
|
||||
if (!req.rawUrl) return;
|
||||
if (!req.url.startsWith("gblob:")) return;
|
||||
|
||||
req.url = URL.createObjectURL(new Blob(["recovered\n"]));
|
||||
ctx.task.continue();
|
||||
});
|
||||
```
|
||||
|
||||
This creates a new `gblob` id, so the downloader restarts from byte `0`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `gblob` is not a network protocol and does not support range requests.
|
||||
- unfinished `gblob` sources do not survive process restarts.
|
||||
- recovery after restart depends on extension logic recreating a new source.
|
||||
- the current restart behavior is full restart on source-id change, not byte-range recovery across recreated sources.
|
||||
- `WritableStream` tasks with unknown total size report size `0` until the writer closes.
|
||||
|
||||
## Summary
|
||||
|
||||
`gblob` lets Gopeed extensions treat generated content as a first-class download source.
|
||||
The registry handles source lifetime and disk spooling.
|
||||
The fetcher turns those sources into normal download tasks with predictable pause/continue behavior.
|
||||
|
||||
The most important rule is simple:
|
||||
|
||||
- same `gblob` id means resume
|
||||
- different `gblob` id means restart
|
||||
@@ -0,0 +1,377 @@
|
||||
package gblob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
type config struct{}
|
||||
|
||||
type Fetcher struct {
|
||||
ctl *controller.Controller
|
||||
registry *Registry
|
||||
meta *fetcher.FetcherMeta
|
||||
doneCh chan error
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
runDone chan struct{}
|
||||
paused bool
|
||||
closed bool
|
||||
|
||||
downloaded atomic.Int64
|
||||
lastSourceID string
|
||||
}
|
||||
|
||||
type fetcherData struct {
|
||||
LastSourceID string `json:"lastSourceId"`
|
||||
}
|
||||
|
||||
func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
f.ctl = ctl
|
||||
if f.meta == nil {
|
||||
f.meta = &fetcher.FetcherMeta{}
|
||||
}
|
||||
f.doneCh = make(chan error, 1)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
if f.registry == nil {
|
||||
return ErrSourceNotFound
|
||||
}
|
||||
src, err := f.registry.Get(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
name := f.meta.Opts.Name
|
||||
if name == "" {
|
||||
name = src.ID
|
||||
}
|
||||
size := src.Snapshot().Written
|
||||
if src.Type == SourceTypeWritableStream {
|
||||
// Writable streams may continue producing bytes after Resolve,
|
||||
// so treat the total size as unknown until the writer closes.
|
||||
size = 0
|
||||
}
|
||||
f.meta.Res = &base.Resource{
|
||||
Size: size,
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: name,
|
||||
Path: "",
|
||||
Size: size,
|
||||
Req: req,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() error {
|
||||
if f.registry == nil {
|
||||
return ErrSourceNotFound
|
||||
}
|
||||
currentSourceID, err := ParseURL(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.runDone != nil {
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
f.paused = false
|
||||
runDone := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f.cancel = cancel
|
||||
f.runDone = runDone
|
||||
f.mu.Unlock()
|
||||
|
||||
path := f.meta.SingleFilepath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
offset := int64(0)
|
||||
if f.lastSourceID != "" && f.lastSourceID != currentSourceID {
|
||||
if err := target.Truncate(0); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := target.Seek(0, io.SeekStart); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
stat, err := target.Stat()
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
}
|
||||
offset = stat.Size()
|
||||
if _, err := target.Seek(offset, io.SeekStart); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
f.lastSourceID = currentSourceID
|
||||
f.downloaded.Store(offset)
|
||||
|
||||
go f.copyLoop(ctx, target, offset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
defer target.Close()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
runDone := f.runDone
|
||||
f.runDone = nil
|
||||
f.cancel = nil
|
||||
paused := f.paused
|
||||
closed := f.closed
|
||||
f.mu.Unlock()
|
||||
close(runDone)
|
||||
if closed {
|
||||
select {
|
||||
case f.doneCh <- nil:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if paused {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
src, err := f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
sourceFile, err := os.Open(src.Path)
|
||||
if err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, offset); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, serr := f.registry.Get(f.meta.Req.URL)
|
||||
if serr != nil {
|
||||
select {
|
||||
case f.doneCh <- serr:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
current := snapshot.Snapshot()
|
||||
for offset < current.Written {
|
||||
remain := current.Written - offset
|
||||
readSize := int64(len(buf))
|
||||
if remain < readSize {
|
||||
readSize = remain
|
||||
}
|
||||
n, err := sourceFile.ReadAt(buf[:readSize], offset)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if _, err := target.Write(buf[:n]); err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
offset += int64(n)
|
||||
f.downloaded.Store(offset)
|
||||
}
|
||||
|
||||
if current.State == SourceStateClosed && offset >= current.Written {
|
||||
if f.meta.Res != nil {
|
||||
f.meta.Res.Size = current.Written
|
||||
f.meta.Res.Files[0].Size = current.Written
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- nil:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
if current.State == SourceStateAborted || current.State == SourceStateFailed {
|
||||
err := current.Err
|
||||
if err == nil {
|
||||
err = ErrSourceAborted
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() error {
|
||||
f.mu.Lock()
|
||||
f.paused = true
|
||||
cancel := f.cancel
|
||||
runDone := f.runDone
|
||||
f.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if runDone != nil {
|
||||
<-runDone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Close() error {
|
||||
f.mu.Lock()
|
||||
f.closed = true
|
||||
cancel := f.cancel
|
||||
runDone := f.runDone
|
||||
f.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if runDone != nil {
|
||||
<-runDone
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- nil:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Stats() any {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
return f.meta
|
||||
}
|
||||
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
return fetcher.Progress{f.downloaded.Load()}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() error {
|
||||
return <-f.doneCh
|
||||
}
|
||||
|
||||
type FetcherManager struct {
|
||||
registry *Registry
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) SetRegistry(registry *Registry) {
|
||||
fm.registry = registry
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Name() string {
|
||||
return Scheme
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||
return []*fetcher.SchemeFilter{
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: strings.ToUpper(Scheme),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||
return &Fetcher{
|
||||
registry: fm.registry,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) ParseName(u string) string {
|
||||
id, err := ParseURL(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) AutoRename() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
return &fetcherData{
|
||||
LastSourceID: _f.lastSourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
fd := v.(*fetcherData)
|
||||
return &Fetcher{
|
||||
registry: fm.registry,
|
||||
meta: meta,
|
||||
doneCh: make(chan error, 1),
|
||||
lastSourceID: fd.LastSourceID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package gblob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
const Scheme = "gblob"
|
||||
|
||||
var (
|
||||
ErrInvalidURL = errors.New("invalid gblob url")
|
||||
ErrSourceNotFound = errors.New("gblob source not found")
|
||||
ErrSourceRevoked = errors.New("gblob source revoked")
|
||||
ErrSourceClosed = errors.New("gblob source closed")
|
||||
ErrSourceAborted = errors.New("gblob source aborted")
|
||||
)
|
||||
|
||||
type SourceType string
|
||||
|
||||
const (
|
||||
SourceTypeBlob SourceType = "blob"
|
||||
SourceTypeWritableStream SourceType = "writable_stream"
|
||||
)
|
||||
|
||||
type SourceState string
|
||||
|
||||
const (
|
||||
SourceStateOpen SourceState = "open"
|
||||
SourceStateClosed SourceState = "closed"
|
||||
SourceStateAborted SourceState = "aborted"
|
||||
SourceStateFailed SourceState = "failed"
|
||||
)
|
||||
|
||||
type SessionRef interface {
|
||||
Retain()
|
||||
Release()
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Written int64
|
||||
State SourceState
|
||||
Err error
|
||||
WaitCh <-chan struct{}
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string
|
||||
URL string
|
||||
Path string
|
||||
Type SourceType
|
||||
ContentType string
|
||||
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
written int64
|
||||
state SourceState
|
||||
err error
|
||||
revoked bool
|
||||
pins int
|
||||
waitCh chan struct{}
|
||||
session SessionRef
|
||||
sessionReleased bool
|
||||
}
|
||||
|
||||
func (s *Source) Snapshot() Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return Snapshot{
|
||||
Written: s.written,
|
||||
State: s.state,
|
||||
Err: s.err,
|
||||
WaitCh: s.waitCh,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) State() SourceState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
func (s *Source) IsRevoked() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.revoked
|
||||
}
|
||||
|
||||
func (s *Source) pin() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pins++
|
||||
}
|
||||
|
||||
func (s *Source) unpin() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.pins > 0 {
|
||||
s.pins--
|
||||
}
|
||||
return s.pins
|
||||
}
|
||||
|
||||
func (s *Source) shouldCleanupLocked() bool {
|
||||
return s.revoked && s.pins == 0 && s.state != SourceStateOpen
|
||||
}
|
||||
|
||||
func (s *Source) notifyLocked() {
|
||||
close(s.waitCh)
|
||||
s.waitCh = make(chan struct{})
|
||||
}
|
||||
|
||||
func (s *Source) releaseSessionLocked() {
|
||||
if s.session != nil && !s.sessionReleased {
|
||||
s.session.Release()
|
||||
s.sessionReleased = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.revoked {
|
||||
return ErrSourceRevoked
|
||||
}
|
||||
if s.state != SourceStateOpen {
|
||||
if s.state == SourceStateAborted || s.state == SourceStateFailed {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
return ErrSourceAborted
|
||||
}
|
||||
return ErrSourceClosed
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
n, err := s.file.Write(data)
|
||||
if err != nil {
|
||||
s.state = SourceStateFailed
|
||||
s.err = err
|
||||
s.file.Close()
|
||||
s.file = nil
|
||||
s.releaseSessionLocked()
|
||||
s.notifyLocked()
|
||||
return err
|
||||
}
|
||||
s.written += int64(n)
|
||||
s.notifyLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Source) close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state != SourceStateOpen {
|
||||
return nil
|
||||
}
|
||||
s.state = SourceStateClosed
|
||||
if s.file != nil {
|
||||
if err := s.file.Close(); err != nil {
|
||||
s.err = err
|
||||
}
|
||||
s.file = nil
|
||||
}
|
||||
s.releaseSessionLocked()
|
||||
s.notifyLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Source) abort(err error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state != SourceStateOpen {
|
||||
return nil
|
||||
}
|
||||
s.state = SourceStateAborted
|
||||
if err != nil {
|
||||
s.err = err
|
||||
}
|
||||
if s.file != nil {
|
||||
_ = s.file.Close()
|
||||
s.file = nil
|
||||
}
|
||||
s.releaseSessionLocked()
|
||||
s.notifyLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Source) revoke() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.revoked = true
|
||||
return s.shouldCleanupLocked()
|
||||
}
|
||||
|
||||
func (s *Source) waitForReadable(ctx context.Context, offset int64) error {
|
||||
for {
|
||||
snapshot := s.Snapshot()
|
||||
if offset < snapshot.Written {
|
||||
return nil
|
||||
}
|
||||
switch snapshot.State {
|
||||
case SourceStateClosed:
|
||||
return nil
|
||||
case SourceStateAborted, SourceStateFailed:
|
||||
if snapshot.Err != nil {
|
||||
return snapshot.Err
|
||||
}
|
||||
return ErrSourceAborted
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-snapshot.WaitCh:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
dir string
|
||||
|
||||
mu sync.RWMutex
|
||||
sources map[string]*Source
|
||||
}
|
||||
|
||||
func NewRegistry(baseDir string) *Registry {
|
||||
dir := baseDir
|
||||
if dir == "" {
|
||||
dir = filepath.Join(os.TempDir(), "gopeed-gblob")
|
||||
}
|
||||
return &Registry{
|
||||
dir: dir,
|
||||
sources: make(map[string]*Source),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildURL(id string) string {
|
||||
return Scheme + ":" + id
|
||||
}
|
||||
|
||||
func ParseURL(raw string) (string, error) {
|
||||
if !strings.HasPrefix(strings.ToLower(raw), Scheme+":") {
|
||||
return "", ErrInvalidURL
|
||||
}
|
||||
id := raw[len(Scheme)+1:]
|
||||
if idx := strings.IndexByte(id, '/'); idx >= 0 {
|
||||
id = id[:idx]
|
||||
}
|
||||
if id == "" {
|
||||
return "", ErrInvalidURL
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef) (*Source, error) {
|
||||
if err := os.MkdirAll(r.dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := gonanoid.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filePath := filepath.Join(r.dir, id)
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session != nil {
|
||||
session.Retain()
|
||||
}
|
||||
src := &Source{
|
||||
ID: id,
|
||||
URL: BuildURL(id),
|
||||
Path: filePath,
|
||||
Type: sourceType,
|
||||
ContentType: contentType,
|
||||
file: file,
|
||||
state: SourceStateOpen,
|
||||
waitCh: make(chan struct{}),
|
||||
session: session,
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.sources[id] = src
|
||||
r.mu.Unlock()
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
|
||||
src, err := r.createSource(SourceTypeBlob, contentType, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := src.write(data); err != nil {
|
||||
_ = os.Remove(src.Path)
|
||||
return "", err
|
||||
}
|
||||
if err := src.close(); err != nil {
|
||||
_ = os.Remove(src.Path)
|
||||
return "", err
|
||||
}
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) CreateWritableStream(session SessionRef) (string, error) {
|
||||
src, err := r.createSource(SourceTypeWritableStream, "", session)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) get(raw string) (*Source, error) {
|
||||
id, err := ParseURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
src := r.sources[id]
|
||||
if src == nil {
|
||||
return nil, ErrSourceNotFound
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Get(raw string) (*Source, error) {
|
||||
return r.get(raw)
|
||||
}
|
||||
|
||||
func (r *Registry) Write(raw string, data []byte) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return src.write(data)
|
||||
}
|
||||
|
||||
func (r *Registry) CloseSource(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := src.close(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cleanupIfNeeded(src)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) AbortSource(raw string, err error) error {
|
||||
src, gerr := r.get(raw)
|
||||
if gerr != nil {
|
||||
return gerr
|
||||
}
|
||||
if err := src.abort(err); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cleanupIfNeeded(src)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src.revoke() {
|
||||
r.cleanupIfNeeded(src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Pin(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src.IsRevoked() {
|
||||
return ErrSourceRevoked
|
||||
}
|
||||
src.pin()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Unpin(raw string) {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
src.unpin()
|
||||
r.cleanupIfNeeded(src)
|
||||
}
|
||||
|
||||
func (r *Registry) WaitForReadable(ctx context.Context, raw string, offset int64) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return src.waitForReadable(ctx, offset)
|
||||
}
|
||||
|
||||
func (r *Registry) cleanupIfNeeded(src *Source) {
|
||||
src.mu.Lock()
|
||||
shouldCleanup := src.shouldCleanupLocked()
|
||||
file := src.file
|
||||
src.mu.Unlock()
|
||||
if !shouldCleanup {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
delete(r.sources, src.ID)
|
||||
r.mu.Unlock()
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
_ = os.Remove(src.Path)
|
||||
}
|
||||
|
||||
func (r *Registry) Close() error {
|
||||
r.mu.Lock()
|
||||
sources := r.sources
|
||||
r.sources = make(map[string]*Source)
|
||||
r.mu.Unlock()
|
||||
var lastErr error
|
||||
for _, src := range sources {
|
||||
src.mu.Lock()
|
||||
if src.file != nil {
|
||||
if err := src.file.Close(); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
src.file = nil
|
||||
}
|
||||
src.releaseSessionLocked()
|
||||
src.notifyLocked()
|
||||
src.mu.Unlock()
|
||||
if err := os.Remove(src.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
Reference in new issue
Block a user