This file contains rules specific to repos/spacewave. Follow the shared
company AGENTS rules first, then apply these Spacewave-specific rules.
- Work from the repository root unless a command explicitly belongs in a subdirectory.
- Keep Spacewave source edits inside this repository. Company workflow files
such as
~/company/notes/,glossary.org, andhotlinks.orgremain governed by the shared company AGENTS rules. - Do not assume
bldr setupneeds to be run manually. Bldr runs setup automatically for almost any operation. - Do not manually copy files to
.bldr/src/or sync source files there..bldr/src/is managed bybldr setupand regenerated automatically. Edit source files in their original locations only. - Do not assume Tailwind utility pixel sizes such as
h-4orh-16; they depend onvar(--spacing). - Styling uses Tailwind v4 with theme variables in
web/style/app.css. - Do not add dead-code fallback paths for impossible conditions. If a field is guaranteed non-nil by construction, do not add defensive nil branches in methods. These fallbacks mask bugs and rot into false invariants.
- Docs describe the current system, not the migration path. Use direct present-state wording in docs, design notes, and tracker summaries.
- Keep this file general-purpose.
AGENTS.mdshould capture repository rules, recurring patterns, and architectural invariants, not task-specific plans or implementation notes. - Do not push to the
releasebranch unless the user explicitly asks. When explicitly asked to push, push commits tomaster; fast-forwardingreleasetomasterhappens only on explicit request.releasemust always equalmasterafter any update, with no merge commits.
-
Temporary local
replacedirectives may use absolute paths so commands keep working when run from subdirectories. Do not commit localreplacepaths; before landing, publish the replaced repo or update to a real module version, remove the localreplace, then tidy and vendor. -
After any
go.modchange, including dependencies, replace directives, or version bumps, run:go mod tidy && go mod vendor -
Keep
vendor/synchronized withgo.mod.
Bldr's Go compiler (bldr/util/gocompiler) signs produced binaries when
platform-appropriate signing env vars are set. The signing hook runs after
go build and before any wasm post-processing. It is a no-op when the relevant
identity/profile env vars are unset.
macOS signing env:
| Env var | Meaning |
|---|---|
BLDR_MACOS_SIGN_IDENTITY |
codesign identity. Unset means skip signing. |
BLDR_MACOS_SIGN_ENTITLEMENTS |
Optional path to an entitlements plist. |
BLDR_MACOS_SIGN_OPTIONS |
Comma-separated codesign --options values. Defaults to runtime. |
When set and GOOS=darwin, bldr runs:
codesign --force --sign "$IDENTITY" --options "$OPTIONS" [--entitlements "$ENTS"] <binary>
codesign --verify --strict <binary>Windows signing env:
| Env var | Meaning |
|---|---|
BLDR_WINDOWS_SIGN_PROFILE |
Trusted Signing certificate profile name. Unset means skip signing. |
BLDR_WINDOWS_SIGN_ACCOUNT |
Trusted Signing signing-account name. Required when profile is set. |
BLDR_WINDOWS_SIGN_ENDPOINT |
Regional endpoint URL. Defaults to https://wus.codesigning.azure.net/. |
BLDR_WINDOWS_SIGN_DESCRIPTION |
Authenticode signature description. Defaults to Spacewave. |
When set and GOOS=windows, bldr shells out to pwsh and
Invoke-TrustedSigning. The machine or CI job must have the TrustedSigning
PowerShell module installed and Azure credentials available through
DefaultAzureCredential (az login locally or azure/login in CI). A non-zero
signing or verification exit fails the build.
Bldr supports file-based logging through the --log-file flag and
BLDR_LOG_FILE environment variable. Implementation lives in
bldr/util/logfile/.
bldr --log-file 'level=DEBUG;format=json;path=.bldr/logs/{ts}.log' start web
BLDR_LOG_FILE='level=WARN;path=.bldr/logs/warn.log' bldr start web
bldr --log-file '.bldr/logs/{ts}.log' start web
BLDR_LOG_FILE=none bldr start webThe short form is a path only and defaults to level=DEBUG;format=text. In dev
mode (--build-type dev), file logging is auto-enabled with
level=DEBUG;path=.bldr/logs/{ts}.log.
Distribution and CLI entrypoints auto-enable a DEBUG text log file when
BLDR_LOG_FILE is unset or blank. The path is <storageRoot>/logs/{ts}.log,
where <storageRoot> is the same directory the binary uses for state, such as
~/.spacewave/. The file stays at DEBUG level regardless of console verbosity.
| Env var | Effect |
|---|---|
BLDR_LOG_FILE=<spec> |
User-specified spec wins; auto-default does not fire. |
BLDR_LOG_FILE=none |
Disables file logging entirely. |
BLDR_LOG_FILE unset or blank |
Auto-default fires at <storageRoot>/logs/{ts}.log. |
<PROJECT>_LOG_LEVEL |
Overrides the console level only. |
BLDR_LOG_LEVEL |
Console-level override checked after <PROJECT>_LOG_LEVEL. |
<PROJECT>_LOG_RETENTION_DAYS |
Overrides retention; default is 7 days. |
Old *.log files in the same directory are pruned at startup before the new
file is created. Pruning failures emit a warning and never abort startup.
EnsureLoggerLevel decouples console and file levels by raising the underlying
logger to DEBUG and routing console output through a level-filtered hook.
For spacewave-cli, the daemon child process inherits the parent CLI
environment, so BLDR_LOG_FILE, BLDR_LOG_LEVEL, SPACEWAVE_LOG_LEVEL,
SPACEWAVE_LOG_RETENTION_DAYS, SPACEWAVE_DATA_DIR, and BLDR_STATE_PATH set
on spacewave-cli start reach the spawned daemon.
- Before wiring a controller to another runtime component, identify which process/plugin owns each side and which RPC, resource, or directive namespace the call travels through.
- Controllerbus directives are local to the bus in that process/plugin. Do not
assume a directive emitted by
spacewave-corecan be handled by controllers in thewebplugin, Electron main, or another plugin process. - Cross-plugin behavior must use an explicit RPC/resource boundary such as
plugin/<id>/...,plugin-host/..., or a Resources SDK surface. If the path crosses plugin boundaries, name the owning plugin/process at each hop before implementing.
- SDK RPCs that return mutable state must be server-streaming
Watch*RPCs, not unaryGet*RPCs. - The UI uses
useStreamingResourcefor server-pushed updates. - Any state that can change from the CLI, another tab, or a background process must be reactive.
- Unary RPCs are appropriate only for immutable values such as session refs and peer IDs, or for one-shot actions such as create, delete, and set.
- If you are adding a
Get*RPC that returns mutable state, make it aWatch*RPC.
Proto3 omits default values from the wire format. protobuf-es-lite leaves
omitted bool fields as undefined after deserialization.
- Check proto bools with
field ?? falseor!!field. - Do not use
field === undefinedto detect "not yet loaded". - For loading state, check whether the containing message is
null.
Example: useStreamingResource returns value: null before the first emission
and value: {} after emitting {setupRequired: false}. Check value for null,
not value.setupRequired for undefined.
RPCs that return resource_id values allocate server-side resources with cleanup
callbacks.
- Wrap returned IDs with
resourceRef.createRef(id)to create aClientResourceRef. - Release refs when the caller is done with them.
- In a fixed async scope, bind each wrapped ref to its own
usingdeclaration. - Use cleanup stacks only for dynamic or cross-helper lifetimes.
- Never discard resource IDs with
void resourceId. - Do not add
Unregister*orRemove*RPCs for resources that already use resource-based lifecycle. Release the resource instead.
useResource(...) retries by default when the loaded value is an SDK Resource
and the client emits server-released for that resource ID.
- Use
retryOnReleasedResource: falseonly when server release is expected and terminal. - For composite or non-
Resourcereturn values, passretryOnReleasedResource: { getResourceIds: ... }.
-
Never make redundant cloud HTTP requests.
-
Account state such as keypairs, account info, and thresholds must be fetched once when the session mounts and cached locally in the Go provider's ObjectStore.
-
Go Watch loops such as
WatchAccountInfoandWatchAuthMethodsserve cached data to the UI through local SRPC. -
Cloud data is refetched only when invalidated by a hash change in the session/register response or by a Session DO WebSocket notify message.
-
Multiple React components subscribing to the same Watch stream must share one Go-side stream.
-
Mutable cloud-backed UI state must use this shape:
UI -> SRPC/watch -> Go cache/tracker state -> cloud sync machinery -
The UI must not trigger cloud fetches just to render current state.
-
If a screen needs mutable cloud-backed state, first add or reuse a Go cache owner such as
ProviderAccount, a session tracker, or a per-SO tracker. -
Known-gated owner-only cloud calls must check cached subscription/lifecycle state on the client and short-circuit locally when the account is inactive, read-only, or dormant.
- Own mutable watches at container boundaries.
- Expose mutable state to the UI through Watch RPCs and subscribe at the nearest
route/container, such as
SessionContainer,SpaceContainer, or an org container. - Pass watched snapshots down through React context.
- Do not start separate mutable watches or fetches in leaf components.
- Batch related low-churn state into one combined watch per screen/domain.
- Do not create one giant watch that couples unrelated high-churn state.
Do not attach shared mutable or persistent state to per-client Resource wrappers.
Resource handles returned by Mount* and Access* RPCs are client-specific
wrappers and may be recreated multiple times for the same underlying session,
account, or object.
Shared state owners such as caches, broadcasts, refcounts, and object-store managers must live on shared domain objects or shared registries keyed by stable identity. Resource wrappers should forward into the shared owner.
Controllers are almost never registered by calling AddFactory directly in Go
production code. Register controllers through bldr.yaml configSet entries:
- Add the controller's Go package to
goPkgsin the manifest builder config. - Add a
configSetentry with the controller'sConfigIDand config fields. - At build time, the Go compiler scans
goPkgsforNewFactoryandBuildFactoriesfunctions and generates aplugin.gowith aFactoriesarray. - At runtime, the plugin registers all factories and deserializes
config-set.bin, matching eachidto a factory'sConfigID.
Direct AddFactory calls belong in tests, such as core/e2e/e2e_test.go.
- Do not use
encoding/jsonin production Go code, and do not add dependencies that pull it in for JSON convenience helpers. Binary size matters in normal builds, not only TinyGo/WASM builds. - Do not use
reflectin production Go code. Reflection-heavy helpers are a binary-size smell; prefer typed code, generated codecs, or explicit parser logic. - For proto messages, use generated
MarshalJSON/UnmarshalJSONorMarshalProtoJSON/UnmarshalProtoJSONfromprotobuf-go-lite/json. - For non-proto HTTP request/response structs, use
aperturerobotics/fastjson. - For raw JSON validation, slicing, passthrough, or structural edits, use
json-iterator-lite,protobuf-go-lite/json, orfastjson. Do not usegabsor other wrappers aroundencoding/json. - Cloud API endpoints define proto messages in
core/provider/spacewave/api/api.protoand use the generated binary codec (MarshalVT/UnmarshalVT) on both sides. Do NOT useMarshalJSON/UnmarshalJSONorMarshalProtoJSON/UnmarshalProtoJSONfor the cloud surface. See the "Cloud HTTP Client" rules below. - For raw JSON passthrough, use a
stringproto field for opaque JSON strings, or[]byteplus fastjson for non-proto raw JSON handling.
All HTTP traffic to repos/spacewave-cloud goes through
core/provider/spacewave/client.go. The approved helpers for cloud calls are:
doPostBinary(ctx, path, reqProto)for POST requests with proto-binary bodies and proto-binary responsesdoGetBinary(ctx, path)for GET requests returning proto-binary responsesdoDelete(ctx, path)for DELETE requestsdoPostStream(ctx, path, reqProto)for streaming responses (sync pull, etc.)doMultiSig(ctx, path, action)for multi-sig action requests; the response unmarshals intoMultiSigActionResponse
Required behaviour for any cloud call:
- request body is
proto.MarshalVT(value)withContent-Type: application/octet-stream - response body is parsed with
proto.UnmarshalVT(value) - every cloud endpoint has both a request proto and a response proto in
core/provider/spacewave/api/, including pure acks (typed-but-empty messages)
Streaming binary responses are an exception to the proto-binary response
rule. Routes that stream bulk bytes from the cloud (packfile downloads,
release artifact downloads, R2 object passthrough, anything where the
cloud streams from R2) carry a raw byte stream as their wire contract,
not a proto schema. Read these via doPostStream / a streaming GET helper
and consume the response body with io.Copy into the destination
sink rather than buffering the full body and decoding a proto.
Forbidden in cloud client code:
doPostJSON(removed; previous proto-JSON helper)aperturerobotics/fastjsonfor cloud requests or responsesMarshalJSON/UnmarshalJSON/MarshalProtoJSON/UnmarshalProtoJSONon proto types crossing the spacewave <-> cloud boundary- hand-rolled JSON request bodies, hand-parsed JSON response bodies
WebSocket frames received from the cloud on the spacewave <-> cloud boundary
are
binary frames carrying a per-endpoint envelope proto with a oneof body case
(WsAuthSessionServerFrame, WsBillingCheckoutServerFrame). Parse with
UnmarshalVT and switch on the oneof. Do NOT call UnmarshalJSON on cloud
WS frames.
In Go HTTP client code, drain unread response body bytes to io.Discard before
closing the body. This preserves keep-alive connection reuse.
If the code reads the full body with io.ReadAll, readResponseBody, or a
streaming copy to EOF, close the body normally.
Never spawn a goroutine from a callback, event handler, WebSocket frame handler,
or other hot path using context.Background() for detached background work.
Use util/routine.RoutineContainer, or StateRoutineContainer when work should
run only in a particular state. The owning long-lived component owns the
lifecycle context and cancels it on close. The callback triggers the routine; it
does not run the work itself.
Pattern:
- Add a lifecycle
ctx context.ContextandctxCancel context.CancelFuncto the owner. Cancel it in the close path. - Construct a
routine.RoutineContainer. - Call
SetRoutinewith the function that performs the work. The routine receives the derived context fromSetContext. - Call
SetContext(o.ctx, true)once to wire lifecycle. - In callback paths, call
RestartRoutine(). - In the close path, call
ClearContext()and then cancel the lifecycle context.
type Owner struct {
ctx context.Context
ctxCancel context.CancelFunc
refresh *routine.RoutineContainer
release func()
}
func NewOwner(le *logrus.Entry) *Owner {
ctx, cancel := context.WithCancel(context.Background())
return &Owner{ctx: ctx, ctxCancel: cancel}
}
func (o *Owner) wireRefresh(bs *blockStore, so *sharedObject) {
o.refresh = routine.NewRoutineContainerWithLogger(o.le)
o.refresh.SetRoutine(func(ctx context.Context) error {
bs.Invalidate()
return so.RefreshSnapshot(ctx)
})
o.refresh.SetContext(o.ctx, true)
o.release = provider.RegisterCallback(func(id string) {
if id != targetID {
return
}
o.refresh.RestartRoutine()
})
}
func (o *Owner) Close() {
if o.release != nil {
o.release()
}
if o.refresh != nil {
o.refresh.ClearContext()
}
o.ctxCancel()
}This rule applies to every case where anonymous goroutines with
context.Background() look convenient. Use lifecycle-scoped primitives from
util/, including routine, keyed, refcount, and broadcast.
When multiple RPC subscribers need to share one background goroutine, such as a
WebSocket connection, use refcount.RefCount from util/refcount.
Pattern:
- Store shared state behind a
broadcast.Broadcast. - Create a
refcount.RefCount[struct{}]whose resolver is the background goroutine. - Each RPC subscriber calls
AddRef, waits on the broadcast for state changes, and callsReleasewhen done. - Call
SetContextwith the parent lifecycle context.
type parent struct {
statusBcast broadcast.Broadcast
status string
ticket string
statusRc *refcount.RefCount[struct{}]
}
func (p *parent) resolveStatusWatcher(
ctx context.Context,
released func(),
) (struct{}, func(), error) {
var ticket string
p.statusBcast.HoldLock(func(_ func(), _ func() <-chan struct{}) {
ticket = p.ticket
})
if ticket == "" {
return struct{}{}, nil, errors.New("no ticket")
}
err := p.runWatcher(ctx, ticket)
return struct{}{}, nil, err
}
func (s *Resource) WatchStatus(
req *WatchReq,
strm WatchStream,
) error {
ctx := strm.Context()
parent := s.getParent()
ref := parent.statusRc.AddRef(nil)
defer ref.Release()
var prev string
for {
var ch <-chan struct{}
var status string
parent.statusBcast.HoldLock(func(_ func(), getWaitCh func() <-chan struct{}) {
ch = getWaitCh()
status = parent.status
})
if status != prev {
if err := strm.Send(&Resp{Status: status}); err != nil {
return err
}
prev = status
}
if isTerminal(status) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
}
}
}Use KeyedRefCount from util/keyed when multiple independent goroutines are
keyed by ID.
SharedObject IDs are ULIDs: 26 lowercase Crockford base32 characters. The block store ID for a SharedObject-backed block store equals the SharedObject ULID verbatim.
SobjectBlockStoreID(soID)incore/provider/local/id.goandcore/provider/spacewave/sobject.goreturnssoIDdirectly. Use the helper for call-site clarity.- Never prefix a SharedObject ULID to form a block store ID.
- Do not introduce translation helpers like
cloudResourceID(bstoreID)orsoIDFromBstoreID(bstoreID). - On the cloud side, the
bstoreIdURL parameter equalssoID. - The same rule applies to other ULID-keyed resources: store the ULID verbatim. Use separate ID columns or typed wrappers for disambiguation.
When calling volume.ExBuildObjectStoreAPI, the volumeID parameter must be
the mounted volume's ID from vol.GetID(), never a raw StorageVolumeID()
string.
The bldr plugin host proxies volumes through an RPC layer that changes volume
IDs. A proxy volume on the plugin bus has the bolt volume ID, such as
hydra/volume/bolt/12D3KooW..., not the original storage volume ID, such as
p/local/{accountID}. Any ExBuildObjectStoreAPI call using a raw storage
volume ID can hang because alias matching does not find the proxy volume.
Correct:
volume.ExBuildObjectStoreAPI(ctx, bus, false, objStoreID, vol.GetID(), cancel)Wrong:
volume.ExBuildObjectStoreAPI(ctx, bus, false, objStoreID, StorageVolumeID(provID, accountID), cancel)External code that needs to mount an ObjectStore must obtain the volume reference from the appropriate provider account. Do not reconstruct the ID from parts.
web/ is the plugin-importable component library. Put code in web/ only if
plugins import it or reasonably would: UI primitives, hooks, SDK wrappers,
ObjectViewer framework, and reusable utilities such as useForgeBlockData.
app/ is application-specific code: object type viewers, pages, session
management, shell components, window chrome, loading screens, and quickstart
flows.
-
Plugins import from
@s4wave/web/only. -
Plugins must never import from
@s4wave/app/. -
app/may import from both@s4wave/web/and@s4wave/app/. -
When adding plugin-importable files under
web/, update the nearestindex.tsbarrel so@s4wave/webexposes the new API through the package entrypoint thatspacewave-webbundles. Prefer plugin imports from these barrels, such as@s4wave/web/contexts/index.js, over direct file subpaths unless the direct subpath is intentionally configured as its own web package entrypoint. -
Verify boundary violations with:
rg "from '@s4wave/app/" web/Exclude
web/test/helpers.tsxwhen evaluating results.
Singleton library APIs must be imported through web/ re-exports. The bldr
build produces separate bundles for spacewave-app and spacewave-web.
Libraries that rely on shared global singleton state can be duplicated across
bundles when imported directly from both.
Example: import toast from @s4wave/web/ui/toaster.js, not from sonner
directly.
Object type viewers are registered statically in app/viewers.tsx and injected
into the web/object/ ObjectViewer framework through ViewerRegistryProvider
from web/hooks/useViewerRegistry.tsx.
The app wraps its root with this provider. The framework reads viewers from
useViewerRegistry() so web/ stays free of app/ imports.
Create a separate plugin under plugin/ when a module has large dependencies
that would bloat the main bundle, such as Lexical or v86.
Merge lightweight viewers and services into spacewave-app with static
registrations in app/viewers.tsx and sdk/. The notes and VM plugins stay
separate.
Components inside the session tree use React contexts instead of parsing URLs.
- Use
useSessionIndex()fromweb/contexts/to get the session index. - Use
usePath()/ router context for the active panel route. Do not derive in-panel navigation or query params fromwindow.location.hashorgetAppPath(): in split/grid mode the global hash is the encoded shell route (#/g/...), not the active panel's/u/<idx>/...route. - Use relative navigation such as
./freeand../setupfor subtree-local moves. - Use
useSessionNavigate()for session-root navigation such asjoin,so/${spaceId}, and dashboard root. - Do not reconstruct
/u/${sessionIndex}/...strings or depend on nested../..path math. SessionIndexContextandSessionRouteContextare set byAppSession.- Prefer context over URL parsing for other session-scoped state as well.
Session indexes start at 1. The mountSessionByIdx Resource SDK call uses
1-based indexes. In AppSession.tsx, parseInt(param ?? '') || null producing
null for index 0 is correct.
TypeScript frontend code must use Go RPCs for crypto, HTTP, and WebSocket operations.
- Do not implement cryptographic operations in TypeScript.
- Do not make direct cloud HTTP requests in TypeScript.
- Do not open raw WebSocket connections in TypeScript.
- Use the Go WASM runtime through in-process StarPC RPCs in the Resource SDK.
- If an RPC does not exist, add one to the proto and implement it in Go.
Use @s4wave/web/state/persist.tsx for UI state that should survive reloads,
such as view modes, collapsed sections, and scroll positions.
import { useStateAtom, useStateNamespace } from '@s4wave/web/state/persist.js'
function Viewer() {
const gitNs = useStateNamespace(['git'])
const [viewMode, setViewMode] = useStateAtom<'files' | 'readme' | 'log'>(
gitNs,
'viewMode',
'files',
)
}SpaceObjectContainer provides a parent namespace
['objectViewer', objectKey]. Viewer components scope beneath it with one
domain prefix:
useStateNamespace(['git'])produces['objectViewer', objectKey, 'git'].useStateNamespace(['canvas'])produces['objectViewer', objectKey, 'canvas'].
Do not include objectKey in the viewer namespace. Do not use an empty
namespace because it collides with other viewer state at the same level.
BottomBarLevelprops must be stable.- Wrap
buttonrenderers inuseCallback. - Wrap
overlayelements inuseMemo. - Pass
buttonKeyandoverlayKeywhenever rendered content should update. - Keys should encode the data that drives the UI, such as selected names, object IDs, or open state.
overlayis read lazily by the root. Update memoized data and bump the key soSessionFramere-renders with fresh content.- Do not return raw
resource_idvalues or inline JSX that depends on stale closures insideBottomBarLevel.
Use Vite static asset imports for images:
import gridPattern from '../images/patterns/grid.png'The imported value is a resolved URL string at build time. Do not use
new URL(..., import.meta.url).href for image assets.
Prioritize React icon libraries in this order:
react-icons/lu(Lucide)react-icons/ri(Remix Icon)react-icons/pi(Phosphor)react-icons/rx(Radix UI)
Use consistent icon families within related components. Use other icon families only when these libraries lack a suitable icon. Prefer filled variants where they match surrounding icons.
Common mappings:
- Chevrons:
LuChevronDown,LuChevronRight,LuChevronLeft,LuChevronUp - Arrows:
LuArrowLeft,LuArrowRight,LuArrowUp,LuArrowDown - UI actions:
LuSearch,LuPlus,LuMenu,LuX,LuCopy - Files:
LuFolder,LuFile,LuHome,LuHardDrive - Media:
LuPlay,LuPause,LuSkipForward,LuSkipBack
Keep these UX laws in mind when working on UI. If you encounter a violation and the fix is outside the current task, flag it before changing scope.
Heuristics:
- Aesthetic-Usability Effect
- Fitt's Law
- Goal-Gradient Effect
- Hick's Law
- Jakob's Law
- Miller's Law
- Parkinson's Law
Principles:
- Doherty Threshold
- Occam's Razor
- Pareto Principle
- Postel's Law
- Tesler's Law
Gestalt:
- Law of Common Region
- Law of Proximity
- Law of Pragnanz
- Law of Similarity
- Law of Uniform Connectedness
Cognitive biases:
- Peak-End Rule
- Serial Position Effect
- Von Restorff Effect
- Zeigarnik Effect
Proto files use Go-style import paths based on Go module names from go.mod.
This module is github.com/s4wave/spacewave.
Local proto files reference each other with the full Go module path:
import "github.com/s4wave/spacewave/core/session/session.proto";
import "github.com/s4wave/spacewave/core/sobject/sobject.proto";External proto files use their external Go module path:
import "github.com/aperturerobotics/controllerbus/bus/bus.proto";
import "github.com/aperturerobotics/starpc/srpc/srpc.proto";Package naming:
sdk/proto files use the fulls4wave.prefix, such aspackage s4wave.space;.core/proto files use shortened package names without the prefix, such aspackage space.world;.- When
sdk/files reference types fromcore/packages, use leading-dot fully qualified references such as.space.world.WorldContents.
When adding TypeScript files that need to be bundled for Electron or browser
entrypoints, add them to the //go:embed directives in dist.go.
DistSources contains TypeScript sources used by esbuild during the build. If a
new .pb.ts file or TypeScript module is imported by files in web/electron/
or web/entrypoint/, it must be explicitly embedded.
Choose the narrowest tier that covers the behavior.
- Unit tests (
*.test.ts) run withvitest runin thehappy-domenvironment. Co-locate them with the module under test and use them for pure logic, data structures, parsers, protocol helpers, and ring buffers. - Browser tests (
*.browser.test.ts,*.e2e.test.ts) run in vitest browser mode with the Playwright provider and headless Chromium. Use them for real browser APIs such as SharedArrayBuffer, Atomics, OPFS, BroadcastChannel, Web Locks, and service workers. - E2E tests (
e2e/*.spec.ts) run withbun run test:e2e, using Playwright directly. The Playwright config starts the dev server withbun run start:web:wasm. Use these for full application lifecycle coverage: page loads, WASM boot, plugin rendering, and console-error checks. - Release E2E tests (
web/entrypoint/browser/*.e2e.spec.ts) run withbun run test:release:web, which builds a release web bundle before testing the static output. - Go tests (
*_test.go) run withgo test ./...and belong beside the Go package they cover. - Do not use prototype directories for production tests. If the company
prototype exception explicitly allows a temporary target-repo probe, keep its
Playwright config and static fixtures isolated from normal vitest projects
and from
bun run test:e2e.
Quick choice:
- New utility function or data structure: unit test.
- New browser API integration: browser test.
- New user-visible feature or startup path: E2E test.
- New Go package or compiler behavior: Go test.
Run all tests with abbreviated output:
bun testcheckThis runs JS unit tests, browser E2E tests, and Go tests, showing only a summary unless something fails.
For full verbose output:
bun run testUse bun run test or bun testcheck, not bun test. bun test invokes Bun's
built-in test runner instead of package scripts.
After code changes, verify with the relevant subset of:
bun run typecheck
bun run lint
go build ./...If .bldr has stale exports or module resolution errors, rebuild it with:
bun run setupPrefer the testbed package and real in-memory running versions of the stack
over mocks.
Use testbed.Default(ctx) for a fully wired bus with volume, logger, and static
resolver. Add real controller factories to the static resolver rather than
mocking interfaces.
Never call h.Navigate() in e2e/wasm/ tests. Navigate calls Playwright
page.Goto(), which triggers a full HTTP page reload and destroys the WASM
process, plugin workers, and WebSocket connections.
Use client-side routing that preserves the WASM process:
page.Evaluate(`() => {
window.history.pushState({}, '', '/target/route')
window.dispatchEvent(new PopStateEvent('popstate'))
}`)The nonavigate linter at lint/nonavigate/ enforces this rule. Build the
custom linter binary with golangci-lint custom using .custom-gcl.yml.
Use core/resource/testbed/testbed_e2e_test.go as the example for adding an
end-to-end test of a Resource SDK implementation.
e2e/wasm suites are opt-in. Set ENABLE_E2E_WASM=true before running
go test against e2e/wasm packages. New e2e/wasm packages need the same
TestMain gate before booting the harness.
When core/e2e tests time out, the issue is often a TypeScript test failure
that does not propagate cleanly. Debug with:
cd core && timeout 35 go test -timeout=30s -v -run TestSpacewaveCoreE2E ./e2e/... 2>&1 | grep -E "panic|ERROR|test failed|test completed"Common causes:
- Proto validation errors. TypeScript tests must populate required proto fields
such as
timestamp. - Missing service implementations. Check whether an unimplemented RPC is being called.
- Directive imbalance. Compare added and removed directives to find stuck lookups.
TypeScript proto mapping:
google.protobuf.Timestampmaps toDate; usenew Date()to populate it.